원본 항목: Part 2-3. 암호화 계열 — 12. 적절하지 않은 난수 값의 사용CWE-330repo 폴더: sd31_weakrandom/ (빌드·실행은 §3-1)
목표 시간: 1회차 15분 / 2회차 9분 / 3회차 6분
Part 2-3에서 유일하게 실제 동작으로 완전히 재현되는 드릴이다 — getrandom() 이 진짜 예측 불가능한 값을 만드는지 직접 확인한다.
이 드릴의 문서 구성 (SD01~SD58 공통)
① 요구사항서 — 어떤 코드가 뚫려 있고, 무엇을 만드나.
② pseudo code 템플릿 — Bad 와 Good 의 뼈대이고, 회차마다 가린다.
③ 테스트 코드 — 공격은 막히고 정상은 통과해야 끝이다.
④ 자주 하는 실수
⑤ 모범답안 — 맨 뒤에 접어 둔다.
변형 A/B/C 는 같은 치환 규칙을 다른 소재로 다시 치는 것이다.
0. 이 드릴로 체화할 것
- srand(seed); for (...) out[i] = "0123456789abcdef"[rand() % 16]; /* seed만 알면 재현됨 */+ if (getrandom(raw, sizeof(raw), 0) != (ssize_t)sizeof(raw)) return -1; /* 실패는 반드시 중단 */+ for (...) sprintf(out + i*2, "%02x", raw[i]);
seed를 바꾸는 것으로는 해결되지 않는다.
rand() 계열은 보안용이 아니며, 커널 CSPRNG(getrandom)로 통째로 교체해야 한다.
회차별 진행
회차
변형
보는 것
순서
1회차
A 세션 토큰 생성
① 요구사항서 ② pseudo code 전체
1. Bad 를 그대로 쳐서 취약점을 관찰한다. 2. Good pseudo 를 한 줄씩 코드로 바꾼다. 3. ③ 테스트를 실행한다.
2회차
B 일회용 비밀번호(OTP) 생성
① 요구사항서 ② pseudo code 의 번호와 제목만 (Level 2)
1. Bad 를 치고 취약점을 확인한다. 2. 번호만 보고 Good 을 쓴다. 3. ③ 테스트를 실행한다.
3회차
C 초기화 벡터(IV) 생성
① 요구사항서만
1. ③ 테스트를 먼저 읽는다. 2. 백지에서 Bad 부터 Good 까지 재현한다.
1회차에 막히면 같은 변형을 한 번 더 친다.
3회차를 통과하면 A 를 백지로 다시 친다.
int gen_token_good(char *out, size_t out_size){ /* [ ] 1. 지역변수 */ /* [ ] 2. 버퍼 크기 검사 */ /* [ ] 3. 커널 CSPRNG */ /* [ ] 4. 16진수로 변환 */ /* [ ] 5. return 0 */}
2-C. gen_token_good — Level 3 (3회차, 백지)
int gen_token_good(char *out, size_t out_size){ /* 아무것도 안 본다. * §1 요구사항서와 §0 의 치환 규칙만 보고 친다. * * 막히면 아래만 찾아본다. * - `getrandom` 의 헤더 (`<sys/random.h>`) 와 `_DEFAULT_SOURCE` * - `seed` 인자가 아예 없다 */}
막혔을 때 찾는 순서
컴파일 에러가 나면 에러 문구를 본다.
unknown type name / implicit declaration 이면 include 누락이다.
man 3 <함수> 를 연다.
SYNOPSIS 맨 위의 #include 를 확인한다.
로직이 막히면 §3 테스트의 기대값을 먼저 읽는다.
공격 입력표(§1)와 테스트가 답이다.
#include <stdio.h>#include <string.h>#include "token_gen.h"static int g_fail = 0;#define T_TRUE(cond, msg) \ do { if (!(cond)) { g_fail++; \ printf(" X %s:%d %s\n", __func__, __LINE__, msg); } } while (0)static void test_bad_is_vulnerable(void){ char t1[33], t2[33]; gen_token_bad(1, t1, sizeof(t1)); gen_token_bad(1, t2, sizeof(t2)); T_TRUE(strcmp(t1, t2) == 0, "Bad는 같은 seed면 항상 같은 토큰을 만들어야 한다(예측 가능함 = 취약점 재현)");}static void test_bad_normal(void){ char t1[33], t2[33]; gen_token_bad(1, t1, sizeof(t1)); gen_token_bad(2, t2, sizeof(t2)); T_TRUE(strcmp(t1, t2) != 0, "적어도 seed가 다르면 결과도 달라진다(그래도 예측 가능한 건 여전함)");}static void test_good_is_unpredictable(void){ char t1[33], t2[33]; T_TRUE(gen_token_good(t1, sizeof(t1)) == 0, "정상 크기 버퍼는 성공해야 한다"); T_TRUE(gen_token_good(t2, sizeof(t2)) == 0, "두 번째 호출도 성공해야 한다"); T_TRUE(strcmp(t1, t2) != 0, "Good은 매 호출마다 다른 토큰을 만들어야 한다(진짜 난수원을 쓴다는 증거)");}static void test_good_rejects_small_buffer(void){ char tiny[4]; T_TRUE(gen_token_good(tiny, sizeof(tiny)) == -1, "Good은 버퍼가 너무 작으면 실패로 알려야 한다");}int main(void){ test_bad_is_vulnerable(); test_bad_normal(); test_good_is_unpredictable(); test_good_rejects_small_buffer(); printf(g_fail ? "FAIL %d\n" : "PASS\n", g_fail); return g_fail ? 1 : 0;}
macOS에서는 getrandom 대신 arc4random_buf
macOS는 <sys/random.h> 의 getrandom() 대신 <stdlib.h> 의 arc4random_buf(buf, len) 을 쓴다(반환값이 없고 항상 성공).
이 드릴을 macOS에서 풀 때는 #ifdef __APPLE__ 로 분기하거나, 실패 처리 로직 없이 arc4random_buf 만 써도 된다.
#define _DEFAULT_SOURCE#include <stdlib.h>#include <stdio.h>#include <sys/random.h>#include "token_gen.h"void gen_token_bad(unsigned seed, char *out, size_t out_size){ size_t i; srand(seed); for (i = 0; i + 1 < out_size; i++) out[i] = "0123456789abcdef"[rand() % 16]; out[i] = '\0';}int gen_token_good(char *out, size_t out_size){ unsigned char raw[16]; size_t i; if (out_size < sizeof(raw) * 2 + 1) return -1; if (getrandom(raw, sizeof(raw), 0) != (ssize_t)sizeof(raw)) return -1; for (i = 0; i < sizeof(raw); i++) sprintf(out + i * 2, "%02x", raw[i]); return 0;}
눈여겨볼 점: gen_token_bad 는 seed 를 인자로 받는다 — 즉 함수 시그니처 자체가 “이 값은 재현 가능하다”고 선언하고 있는 셈이다.
gen_token_good 에는 seed 인자가 아예 없다.
함수가 무엇을 인자로 받는지가 곧 그 함수의 예측 가능성을 말해준다.